/* 
 * Copyright 2014 Igor Maznitsa (http://www.igormaznitsa.com).
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.wordpress.tips4java;

import com.igormaznitsa.prol.easygui.AbstractProlEditor;
import java.awt.*;
import java.beans.*;
import java.util.HashMap;
import java.util.logging.Logger;
import java.util.prefs.Preferences;
import javax.swing.*;
import javax.swing.border.*;
import javax.swing.event.*;
import javax.swing.text.*;
import javax.swing.undo.UndoManager;

/**
 * The class implements the source editor pane of the IDE.
 * As the Base for the class I have used the open sources class developed by Rob Camick and placed at http://tips4java.wordpress.com/2009/05/23/text-component-line-number/
 */
public class TextLineNumber extends AbstractProlEditor {
  private static final long serialVersionUID = -2223529439306867844L;

  /**
   * Inside logger, the logger id = "PROL_NOTE_PAD"
   */
  protected static final Logger LOG = Logger.getLogger("PROL_NOTE_PAD");

  /**
   * The component allows to add the number line counter in the editor
   */
  private static final class LineNumberComponent extends JPanel implements CaretListener, DocumentListener, PropertyChangeListener {
    private static final long serialVersionUID = -981371742373160068L;

    public final static float LEFT = 0.0f;
    public final static float CENTER = 0.5f;
    public final static float RIGHT = 1.0f;
    private final static Border OUTER = new MatteBorder(0, 0, 0, 2, Color.GRAY);
    private final static int HGHT = Integer.MAX_VALUE - 1000000;
    //  Text component this TextTextLineNumber component is in sync with
    private JTextComponent component;
    //  Properties that can be changed
    private boolean updateFont;
    private int borderGap;
    private Color currentLineForeground;
    private float digitAlignment;
    private int minimumDisplayDigits;
    //  Keep history information to reduce the number of times the component
    //  needs to be repainted
    private int lastDigits;
    private int lastHeight;
    private int lastLine;
    private HashMap<String, FontMetrics> fonts;

    /**
     * Create a line number component for a text component. This minimum display
     * width will be based on 3 digits.
     *
     * @param component the related text component
     */
    public LineNumberComponent(JTextComponent component) {
      this(component, 3);
    }

    /**
     * Create a line number component for a text component.
     *
     * @param component the related text component
     * @param minimumDisplayDigits the number of digits used to calculate the
     * minimum width of the component
     */
    public LineNumberComponent(JTextComponent component, int minimumDisplayDigits) {
      this.component = component;

      setBackground(Color.LIGHT_GRAY);

      setFont(component.getFont());

      setBorderGap(5);
      setCurrentLineForeground(Color.YELLOW);
      setForeground(Color.DARK_GRAY);
      setDigitAlignment(RIGHT);
      setMinimumDisplayDigits(minimumDisplayDigits);

      component.getDocument().addDocumentListener(this);
      component.addCaretListener(this);
      component.addPropertyChangeListener("font", this);
    }

    /**
     * Gets the update font property
     *
     * @return the update font property
     */
    public boolean getUpdateFont() {
      return updateFont;
    }

    /**
     * Set the update font property. Indicates whether this Font should be
     * updated automatically when the Font of the related text component is
     * changed.
     *
     * @param updateFont when true update the Font and repaint the line numbers,
     * otherwise just repaint the line numbers.
     */
    public void setUpdateFont(boolean updateFont) {
      this.updateFont = updateFont;
    }

    /**
     * Gets the border gap
     *
     * @return the border gap in pixels
     */
    public int getBorderGap() {
      return borderGap;
    }

    /**
     * The border gap is used in calculating the left and right insets of the
     * border. Default value is 5.
     *
     * @param borderGap the gap in pixels
     */
    public void setBorderGap(int borderGap) {
      this.borderGap = borderGap;
      Border inner = new EmptyBorder(0, borderGap, 0, borderGap);
      setBorder(new CompoundBorder(OUTER, inner));
      lastDigits = 0;
      setPreferredWidth();
    }

    /**
     * Gets the current line rendering Color
     *
     * @return the Color used to render the current line number
     */
    public Color getCurrentLineForeground() {
      return currentLineForeground == null ? getForeground() : currentLineForeground;
    }

    /**
     * The Color used to render the current line digits. Default is Coolor.RED.
     *
     * @param currentLineForeground the Color used to render the current line
     */
    public void setCurrentLineForeground(Color currentLineForeground) {
      this.currentLineForeground = currentLineForeground;
    }

    /**
     * Gets the digit alignment
     *
     * @return the alignment of the painted digits
     */
    public float getDigitAlignment() {
      return digitAlignment;
    }

    /**
     * Specify the horizontal alignment of the digits within the component.
     * Common values would be:
     * <ul>
     * <li>TextLineNumber.LEFT
     * <li>TextLineNumber.CENTER
     * <li>TextLineNumber.RIGHT (default)
     * </ul>
     *
     * @param currentLineForeground the Color used to render the current line
     */
    public void setDigitAlignment(float digitAlignment) {
      this.digitAlignment = digitAlignment > 1.0f ? 1.0f : digitAlignment < 0.0f ? -1.0f : digitAlignment;
    }

    /**
     * Gets the minimum display digits
     *
     * @return the minimum display digits
     */
    public int getMinimumDisplayDigits() {
      return minimumDisplayDigits;
    }

    /**
     * Specify the minimum number of digits used to calculate the preferred
     * width of the component. Default is 3.
     *
     * @param minimumDisplayDigits the number digits used in the preferred width
     * calculation
     */
    public void setMinimumDisplayDigits(int minimumDisplayDigits) {
      this.minimumDisplayDigits = minimumDisplayDigits;
      setPreferredWidth();
    }

    /**
     * Calculate the width needed to display the maximum line number
     */
    private void setPreferredWidth() {
      Element root = component.getDocument().getDefaultRootElement();
      int lines = root.getElementCount();
      int digits = Math.max(String.valueOf(lines).length(), minimumDisplayDigits);

      //  Update sizes when number of digits in the line number changes
      if (lastDigits != digits) {
        lastDigits = digits;
        FontMetrics fontMetrics = getFontMetrics(getFont());
        int width = fontMetrics.charWidth('0') * digits;
        Insets insets = getInsets();
        int preferredWidth = insets.left + insets.right + width;

        Dimension d = getPreferredSize();
        d.setSize(preferredWidth, HGHT);
        setPreferredSize(d);
        setSize(d);
      }
    }

    /**
     * Draw the line numbers
     */
    @Override
    public void paintComponent(Graphics g) {
      super.paintComponent(g);

      //	Determine the width of the space available to draw the line number
      FontMetrics fontMetrics = component.getFontMetrics(component.getFont());
      Insets insets = getInsets();
      int availableWidth = getSize().width - insets.left - insets.right;

      //  Determine the rows to draw within the clipped bounds.
      Rectangle clip = g.getClipBounds();
      int rowStartOffset = component.viewToModel(new Point(0, clip.y));
      int endOffset = component.viewToModel(new Point(0, clip.y + clip.height));

      while (rowStartOffset <= endOffset) {
        try {
          if (isCurrentLine(rowStartOffset)) {
            g.setColor(getCurrentLineForeground());
          }
          else {
            g.setColor(getForeground());
          }

          //  Get the line number as a string and then determine the
          //  "X" and "Y" offsets for drawing the string.
          String lineNumber = getTextLineNumber(rowStartOffset);
          int stringWidth = fontMetrics.stringWidth(lineNumber);
          int x = getOffsetX(availableWidth, stringWidth) + insets.left;
          int y = getOffsetY(rowStartOffset, fontMetrics);
          g.drawString(lineNumber, x, y);

          //  Move to the next row
          rowStartOffset = Utilities.getRowEnd(component, rowStartOffset) + 1;
        }
        catch (Exception e) {
        }
      }
    }

    /*
     *  We need to know if the caret is currently positioned on the line we
     *  are about to paint so the line number can be highlighted.
     */
    private boolean isCurrentLine(int rowStartOffset) {
      int caretPosition = component.getCaretPosition();
      Element root = component.getDocument().getDefaultRootElement();

      if (root.getElementIndex(rowStartOffset) == root.getElementIndex(caretPosition)) {
        return true;
      }
      else {
        return false;
      }
    }

    /*
     *	Get the line number to be drawn. The empty string will be returned
     *  when a line of text has wrapped.
     */
    protected final String getTextLineNumber(final int rowStartOffset) {
      final Element root = component.getDocument().getDefaultRootElement();
      final int index = root.getElementIndex(rowStartOffset);
      final Element line = root.getElement(index);

      if (line.getStartOffset() == rowStartOffset) {
        return String.valueOf(index + 1);
      }
      else {
        return "";
      }
    }

    /*
     *  Determine the X offset to properly align the line number when drawn
     */
    private int getOffsetX(final int availableWidth, final int stringWidth) {
      return (int) ((availableWidth - stringWidth) * digitAlignment);
    }

    /*
     *  Determine the Y offset for the current row
     */
    private int getOffsetY(final int rowStartOffset, final FontMetrics fontMetrics)
            throws BadLocationException {
      //  Get the bounding rectangle of the row

      final Rectangle r = component.modelToView(rowStartOffset);
      final int lineHeight = fontMetrics.getHeight();
      final int y = r.y + r.height;
      int descent = 0;

      //  The text needs to be positioned above the bottom of the bounding
      //  rectangle based on the descent of the font(s) contained on the row.
      if (r.height == lineHeight) // default font is being used
      {
        descent = fontMetrics.getDescent();
      }
      else // We need to check all the attributes for font changes
      {
        if (fonts == null) {
          fonts = new HashMap<String, FontMetrics>();
        }

        final Element root = component.getDocument().getDefaultRootElement();
        final int index = root.getElementIndex(rowStartOffset);
        final Element line = root.getElement(index);

        for (int i = 0; i < line.getElementCount(); i++) {
          final Element child = line.getElement(i);
          final AttributeSet attrset = child.getAttributes();
          final String fontFamily = (String) attrset.getAttribute(StyleConstants.FontFamily);
          final Integer fontSize = (Integer) attrset.getAttribute(StyleConstants.FontSize);
          final String key = fontFamily + fontSize;

          FontMetrics fntmtrcs = fonts.get(key);

          if (fntmtrcs == null) {
            final Font font = new Font(fontFamily, Font.PLAIN, fontSize);
            fntmtrcs = component.getFontMetrics(font);
            fonts.put(key, fntmtrcs);
          }

          descent = Math.max(descent, fntmtrcs.getDescent());
        }
      }

      return y - descent;
    }

//
//  Implement CaretListener interface
//
    @Override
    public final void caretUpdate(final CaretEvent e) {
      //  Get the line the caret is positioned on

      final int caretPosition = component.getCaretPosition();
      final Element root = component.getDocument().getDefaultRootElement();
      final int currentLine = root.getElementIndex(caretPosition);

      //  Need to repaint so the correct line number can be highlighted
      if (lastLine != currentLine) {
        repaint();
        lastLine = currentLine;
      }
    }

//
//  Implement DocumentListener interface
//
    @Override
    public void changedUpdate(DocumentEvent e) {
      documentChanged();
    }

    @Override
    public void insertUpdate(DocumentEvent e) {
      documentChanged();
    }

    @Override
    public void removeUpdate(DocumentEvent e) {
      documentChanged();
    }

    /*
     *  A document change may affect the number of displayed lines of text.
     *  Therefore the lines numbers will also change.
     */
    private void documentChanged() {
      //  Preferred size of the component has not been updated at the time
      //  the DocumentEvent is fired

      SwingUtilities.invokeLater(new Runnable() {

        @Override
        public void run() {
          int preferredHeight = component.getPreferredSize().height;

          //  Document change has caused a change in the number of lines.
          //  Repaint to reflect the new line numbers
          if (lastHeight != preferredHeight) {
            setPreferredWidth();
            repaint();
            lastHeight = preferredHeight;
          }
        }
      });
    }

//
//  Implement PropertyChangeListener interface
//
    @Override
    public void propertyChange(final PropertyChangeEvent evt) {
      if (evt.getNewValue() instanceof Font) {
        if (updateFont) {
          final Font newFont = (Font) evt.getNewValue();
          setFont(newFont);
          lastDigits = 0;
          setPreferredWidth();
        }
        else {
          repaint();
        }
      }
    }
  }
  protected UndoManager undoManager;

  public synchronized String getText() {
    return editor.getText();
  }

  public synchronized void setWordWrap(final boolean flag) {
    editor.setWordWrap(flag);
  }

  public synchronized boolean isWordWrap() {
    return editor.isWordWrap();
  }

  public synchronized UndoManager getUndoManager() {
    return undoManager;
  }

  public synchronized void addUndoableEditListener(final UndoableEditListener listener) {
    editor.getDocument().addUndoableEditListener(listener);
  }

  public synchronized void addDocumentListener(final DocumentListener listener) {
    editor.getDocument().addDocumentListener(listener);
  }

  public TextLineNumber() {
    super("Editor");

    editor.setContentType("text/prol");

    removePropertyFromList("EdWordWrap");

    editor.setForeground(Color.BLACK);
    editor.setBackground(Color.WHITE);
    editor.setCaretColor(Color.BLACK);
    editor.setFont(new Font("Courier", Font.BOLD, 14));
    
    editor.setVisible(true);
    
    setEnabled(true);

    undoManager = new UndoManager();

    final LineNumberComponent lineNumerator = new LineNumberComponent(editor);
    lineNumerator.setUpdateFont(true);

    scrollPane.setRowHeaderView(lineNumerator);
  }

  public synchronized int getCaretPosition() {
    return this.editor.getCaretPosition();
  }

  public synchronized void setCaretPosition(final int pos){
    this.editor.setCaretPosition(pos);
    this.editor.getCaret().setVisible(true);
  }
  
  public synchronized void setCaretPosition(final int line, final int pos) {
    try {
      final Element rootelement = editor.getDocument().getDefaultRootElement();

      final Element element = rootelement.getElement(line - 1);
      final int offset = element.getStartOffset() + pos - 1;

      editor.setCaretPosition(offset);
      editor.requestFocus();
    }
    catch (Exception ex) {
      LOG.throwing(this.getClass().getCanonicalName(), "setCaretPosition()", ex);
    }
  }

  @Override
  public void loadPreferences(final Preferences prefs) {
    setEdBackground(new Color(prefs.getInt("sourceedbackcolor", 0x1437AD)));
    setEdCaretColor(new Color(prefs.getInt("sourcecaretcolor", 0xFFFF40)));
    setEdForeground(new Color(prefs.getInt("sourceforegroundcolor", 0xFFFFFF)));
    setEdWordWrap(prefs.getBoolean("sourcewordwrap", true));
    setEdFont(loadFontFromPrefs(prefs, "sourcefont"));
  }

  @Override
  public void savePreferences(final Preferences prefs) {
    prefs.putInt("sourceedbackcolor", getEdBackground().getRGB());
    prefs.putInt("sourcecaretcolor", getEdCaretColor().getRGB());
    prefs.putInt("sourceforegroundcolor", getEdForeground().getRGB());
    prefs.putBoolean("sourcewordwrap", getEdWordWrap());
    saveFontToPrefs(prefs, "sourcefont", editor.getFont());
  }

  public boolean uncommentSelectedLines() {
    if (editor.getDocument().getLength() == 0) {
      return false;
    }

    int selectionStart = editor.getSelectionStart();
    int selectionEnd = editor.getSelectionEnd();

    if (selectionStart < 0 || selectionEnd < 0) {
      selectionStart = editor.getCaretPosition();
      selectionEnd = selectionStart;
    }

    final Element root = editor.getDocument().getDefaultRootElement();
    final int startElement = root.getElementIndex(selectionStart);
    final int endElement = root.getElementIndex(selectionEnd);

    boolean result = false;

    for (int i = startElement; i <= endElement; i++) {
      final Element elem = root.getElement(i);
      try {
        final String elementtext = elem.getDocument().getText(elem.getStartOffset(), elem.getEndOffset() - elem.getStartOffset());
        if (elementtext.trim().startsWith("%")) {
          final int indexofcomment = elementtext.indexOf('%');
          if (indexofcomment >= 0) {
            elem.getDocument().remove(elem.getStartOffset() + indexofcomment, 1);
            result = true;
          }
        }
      }
      catch (BadLocationException ex) {
        LOG.throwing(this.getClass().getCanonicalName(), "uncommentSelectedLines()", ex);
      }
    }
    editor.revalidate();
    return result;
  }

  public boolean commentSelectedLines() {
    if (editor.getDocument().getLength() == 0) {
      return false;
    }

    int selectionStart = editor.getSelectionStart();
    int selectionEnd = editor.getSelectionEnd();

    if (selectionStart < 0 || selectionEnd < 0) {
      selectionStart = editor.getCaretPosition();
      selectionEnd = selectionStart;
    }

    final Element root = editor.getDocument().getDefaultRootElement();
    final int startElement = root.getElementIndex(selectionStart);
    final int endElement = root.getElementIndex(selectionEnd);

    boolean result = false;

    for (int i = startElement; i <= endElement; i++) {
      final Element elem = root.getElement(i);
      try {
        final String elementtext = elem.getDocument().getText(elem.getStartOffset(), elem.getEndOffset() - elem.getStartOffset());
        if (!elementtext.trim().startsWith("%")) {
          elem.getDocument().insertString(elem.getStartOffset(), "%", null);
          result = true;
        }
      }
      catch (BadLocationException ex) {
        LOG.throwing(this.getClass().getCanonicalName(), "commentSelectedLines()", ex);
      }
    }
    editor.revalidate();
    return result;
  }

  @Override
  public boolean doesSupportTextCut() {
    return true;
  }

  @Override
  public boolean doesSupportTextPaste() {
    return true;
  }

}
